/**
 * This test runs in Node so no browser auto-detection is done. Instead, a
 * FakeHandler device is used.
 */

import * as sdpTransform from 'sdp-transform';
import { AwaitQueue } from 'awaitqueue';
import { FakeMediaStreamTrack } from 'fake-mediastreamtrack';
import * as mediasoupClient from '../';
import { UnsupportedError, InvalidStateError } from '../errors';
import * as utils from '../utils';
import { RemoteSdp } from '../handlers/sdp/RemoteSdp';
import { FakeHandler } from '../handlers/FakeHandler';
import { RtpCapabilities } from '../RtpParameters';
import * as fakeParameters from './fakeParameters';
import { uaTestCases } from './uaTestCases';

const {
	Device,
	detectDevice,
	parseScalabilityMode,
	debug
} = mediasoupClient;

let device: mediasoupClient.types.Device;
let sendTransport: mediasoupClient.types.Transport;
let recvTransport: mediasoupClient.types.Transport;
let audioProducer: mediasoupClient.types.Producer;
let videoProducer: mediasoupClient.types.Producer;
let audioConsumer: mediasoupClient.types.Consumer;
let videoConsumer: mediasoupClient.types.Consumer;
let dataProducer: mediasoupClient.types.DataProducer;
let dataConsumer: mediasoupClient.types.DataConsumer;

test('mediasoup-client exposes debug dependency', () =>
{
	expect(typeof debug).toBe('function');
}, 500);

test('detectDevice() returns nothing in Node', () =>
{
	expect(detectDevice()).toBe(undefined);
});

test('create a Device in Node without custom handlerName/handlerFactory throws UnsupportedError', () =>
{
	expect(() => new Device())
		.toThrow(UnsupportedError);
});

test('create a Device with an unknown handlerName string throws TypeError', () =>
{
	// @ts-ignore
	expect(() => new Device({ handlerName: 'FooBrowser666' }))
		.toThrow(TypeError);
});

test('create a Device in Node with a valid handlerFactory succeeds', () =>
{
	device = new Device({ handlerFactory: FakeHandler.createFactory(fakeParameters) });

	expect(typeof device).toBe('object');
	expect(device.handlerName).toBe('FakeHandler');
	expect(device.loaded).toBe(false);
});

test('device.rtpCapabilities getter throws InvalidStateError if not loaded', () =>
{
	expect(() => device.rtpCapabilities)
		.toThrow(InvalidStateError);
});

test('device.sctpCapabilities getter throws InvalidStateError if not loaded', () =>
{
	expect(() => device.sctpCapabilities)
		.toThrow(InvalidStateError);
});

test('device.canProduce() throws InvalidStateError if not loaded', () =>
{
	expect(() => device.canProduce('audio'))
		.toThrow(InvalidStateError);
});

test('device.createSendTransport() throws InvalidStateError if not loaded', () =>
{
	const {
		id,
		iceParameters,
		iceCandidates,
		dtlsParameters,
		sctpParameters
	} = fakeParameters.generateTransportRemoteParameters();

	expect(() => device.createSendTransport(
		{
			id,
			iceParameters,
			iceCandidates,
			dtlsParameters,
			sctpParameters
		}))
		.toThrow(InvalidStateError);
});

test('device.load() without routerRtpCapabilities rejects with TypeError', async () =>
{
	// @ts-ignore
	await expect(device.load({}))
		.rejects
		.toThrow(TypeError);

	expect(device.loaded).toBe(false);
}, 500);

test('device.load() with invalid routerRtpCapabilities rejects with TypeError', async () =>
{
	// Clonse fake router RTP capabilities to make them invalid.
	const routerRtpCapabilities =
		utils.clone<RtpCapabilities>(fakeParameters.generateRouterRtpCapabilities());

	for (const codec of routerRtpCapabilities.codecs!)
	{
		// @ts-ignore
		delete codec!.mimeType;
	}

	await expect(device.load({ routerRtpCapabilities }))
		.rejects
		.toThrow(TypeError);

	expect(device.loaded).toBe(false);
}, 500);

test('device.load() succeeds', async () =>
{
	// Assume we get the router RTP capabilities.
	const routerRtpCapabilities = fakeParameters.generateRouterRtpCapabilities();

	await expect(device.load({ routerRtpCapabilities }))
		.resolves
		.toBe(undefined);

	expect(device.loaded).toBe(true);
}, 500);

test('device.load() rejects with InvalidStateError if already loaded', async () =>
{
	// @ts-ignore
	await expect(device.load({}))
		.rejects
		.toThrow(InvalidStateError);

	expect(device.loaded).toBe(true);
}, 500);

test('device.rtpCapabilities getter succeeds', () =>
{
	expect(typeof device.rtpCapabilities).toBe('object');
});

test('device.sctpCapabilities getter succeeds', () =>
{
	expect(typeof device.sctpCapabilities).toBe('object');
});

test('device.canProduce() with "audio"/"video" kind returns true', () =>
{
	expect(device.canProduce('audio')).toBe(true);
	expect(device.canProduce('video')).toBe(true);
});

test('device.canProduce() with invalid kind throws TypeError', () =>
{
	// @ts-ignore
	expect(() => device.canProduce('chicken'))
		.toThrow(TypeError);
});

test('device.createSendTransport() for sending media succeeds', () =>
{
	// Assume we create a transport in the server and get its remote parameters.
	const {
		id,
		iceParameters,
		iceCandidates,
		dtlsParameters,
		sctpParameters
	} = fakeParameters.generateTransportRemoteParameters();

	sendTransport = device.createSendTransport<{ foo: number }>(
		{
			id,
			iceParameters,
			iceCandidates,
			dtlsParameters,
			sctpParameters,
			appData : { foo: 123 }
		});

	expect(typeof sendTransport).toBe('object');
	expect(sendTransport.id).toBe(id);
	expect(sendTransport.closed).toBe(false);
	expect(sendTransport.direction).toBe('send');
	expect(typeof sendTransport.handler).toBe('object');
	expect(sendTransport.handler instanceof FakeHandler).toBe(true);
	expect(sendTransport.connectionState).toBe('new');
	expect(sendTransport.appData).toEqual({ foo: 123 });
});

test('device.createRecvTransport() for receiving media succeeds', () =>
{
	// Assume we create a transport in the server and get its remote parameters.
	const {
		id,
		iceParameters,
		iceCandidates,
		dtlsParameters,
		sctpParameters
	} = fakeParameters.generateTransportRemoteParameters();

	recvTransport = device.createRecvTransport(
		{
			id,
			iceParameters,
			iceCandidates,
			dtlsParameters,
			sctpParameters
		});

	expect(typeof recvTransport).toBe('object');
	expect(recvTransport.id).toBe(id);
	expect(recvTransport.closed).toBe(false);
	expect(recvTransport.direction).toBe('recv');
	expect(typeof recvTransport.handler).toBe('object');
	expect(recvTransport.handler instanceof FakeHandler).toBe(true);
	expect(recvTransport.connectionState).toBe('new');
	expect(recvTransport.appData).toEqual({});
});

test('device.createSendTransport() with missing remote Transport parameters throws TypeError', () =>
{
	// @ts-ignore
	expect(() => device.createSendTransport({ id: '1234' }))
		.toThrow(TypeError);

	// @ts-ignore
	expect(() => device.createSendTransport({ id: '1234', iceParameters: {} }))
		.toThrow(TypeError);

	expect(() => device.createSendTransport(
		{
			id            : '1234',
			// @ts-ignore
			iceParameters : {},
			iceCandidates : []
		}))
		.toThrow(TypeError);
});

test('device.createRecvTransport() with a non object appData throws TypeError', () =>
{
	const {
		id,
		iceParameters,
		iceCandidates,
		dtlsParameters,
		sctpParameters
	} = fakeParameters.generateTransportRemoteParameters();

	expect(() => device.createRecvTransport(
		{
			id,
			iceParameters,
			iceCandidates,
			dtlsParameters,
			sctpParameters,
			// @ts-ignore
			appData : 1234
		}))
		.toThrow(TypeError);
});

test('transport.produce() without "connect" listener rejects', async () =>
{
	const audioTrack = new FakeMediaStreamTrack({ kind: 'audio' });

	await expect(sendTransport.produce({ track: audioTrack }))
		.rejects
		.toThrow(Error);
}, 500);

test('transport.produce() succeeds', async () =>
{
	const audioTrack = new FakeMediaStreamTrack({ kind: 'audio' });
	const videoTrack = new FakeMediaStreamTrack({ kind: 'video' });
	let audioProducerId;
	let videoProducerId;
	let connectEventNumTimesCalled = 0;
	let produceEventNumTimesCalled = 0;

	// eslint-disable-next-line no-unused-vars
	sendTransport.on('connect', ({ dtlsParameters }, callback /* errback */) =>
	{
		connectEventNumTimesCalled++;

		expect(typeof dtlsParameters).toBe('object');

		// Emulate communication with the server and success response (no response
		// data needed).
		setTimeout(callback);
	});

	// eslint-disable-next-line no-unused-vars
	sendTransport.on('produce', ({ kind, rtpParameters, appData }, callback /* errback */) =>
	{
		produceEventNumTimesCalled++;

		expect(typeof kind).toBe('string');
		expect(typeof rtpParameters).toBe('object');

		let id: string;

		switch (kind)
		{
			case 'audio':
			{
				expect(appData).toEqual({ foo: 'FOO' });

				id = fakeParameters.generateProducerRemoteParameters().id;
				audioProducerId = id;

				break;
			}

			case 'video':
			{
				expect(appData).toEqual({});

				id = fakeParameters.generateProducerRemoteParameters().id;
				videoProducerId = id;

				break;
			}

			default:
			{
				throw new Error('unknown kind');
			}
		}

		// Emulate communication with the server and success response with Producer
		// remote parameters.
		setTimeout(() => callback({ id }));
	});

	let codecs;
	let headerExtensions;
	let encodings;
	let rtcp;

	// Pause the audio track before creating its Producer.
	audioTrack.enabled = false;

	// Use stopTracks: false.
	audioProducer = await sendTransport.produce<{ foo: string }>(
		{ track: audioTrack, stopTracks: false, appData: { foo: 'FOO' } });

	expect(connectEventNumTimesCalled).toBe(1);
	expect(produceEventNumTimesCalled).toBe(1);
	expect(typeof audioProducer).toBe('object');
	expect(audioProducer.id).toBe(audioProducerId);
	expect(audioProducer.closed).toBe(false);
	expect(audioProducer.kind).toBe('audio');
	expect(audioProducer.track).toBe(audioTrack);
	expect(typeof audioProducer.rtpParameters).toBe('object');
	expect(typeof audioProducer.rtpParameters.mid).toBe('string');
	expect(audioProducer.rtpParameters.codecs.length).toBe(1);

	codecs = audioProducer.rtpParameters.codecs;

	expect(codecs[0]).toEqual(
		{
			mimeType     : 'audio/opus',
			payloadType  : 111,
			clockRate    : 48000,
			channels     : 2,
			rtcpFeedback :
			[
				{ type: 'transport-cc', parameter: '' }
			],
			parameters :
			{
				minptime     : 10,
				useinbandfec : 1
			}
		});

	headerExtensions = audioProducer.rtpParameters.headerExtensions;

	expect(headerExtensions).toEqual(
		[
			{
				uri        : 'urn:ietf:params:rtp-hdrext:sdes:mid',
				id         : 1,
				encrypt    : false,
				parameters : {}
			},
			{
				uri        : 'urn:ietf:params:rtp-hdrext:ssrc-audio-level',
				id         : 10,
				encrypt    : false,
				parameters : {}
			}
		]);

	encodings = audioProducer.rtpParameters.encodings;

	expect(Array.isArray(encodings)).toBe(true);
	expect(encodings!.length).toBe(1);
	expect(typeof encodings?.[0]).toBe('object');
	expect(Object.keys(encodings![0])).toEqual([ 'ssrc', 'dtx' ]);
	expect(typeof encodings?.[0].ssrc).toBe('number');

	rtcp = audioProducer.rtpParameters.rtcp;

	expect(typeof rtcp).toBe('object');
	expect(typeof rtcp?.cname).toBe('string');

	expect(audioProducer.paused).toBe(true);
	expect(audioProducer.maxSpatialLayer).toBe(undefined);
	expect(audioProducer.appData).toEqual({ foo: 'FOO' });

	// Reset the audio paused state.
	audioProducer.resume();

	const videoEncodings =
	[
		{ maxBitrate: 100000 },
		{ maxBitrate: 500000 }
	];

	// Note that stopTracks is not give so it's true by default.
	// Use disableTrackOnPause: false and zeroRtpOnPause: true
	videoProducer = await sendTransport.produce(
		{
			track               : videoTrack,
			encodings           : videoEncodings,
			disableTrackOnPause : false,
			zeroRtpOnPause      : true
		});

	expect(connectEventNumTimesCalled).toBe(1);
	expect(produceEventNumTimesCalled).toBe(2);
	expect(typeof videoProducer).toBe('object');
	expect(videoProducer.id).toBe(videoProducerId);
	expect(videoProducer.closed).toBe(false);
	expect(videoProducer.kind).toBe('video');
	expect(videoProducer.track).toBe(videoTrack);
	expect(typeof videoProducer.rtpParameters).toBe('object');
	expect(typeof videoProducer.rtpParameters.mid).toBe('string');
	expect(videoProducer.rtpParameters.codecs.length).toBe(2);

	codecs = videoProducer.rtpParameters.codecs;

	expect(codecs[0]).toEqual(
		{
			mimeType     : 'video/VP8',
			payloadType  : 96,
			clockRate    : 90000,
			rtcpFeedback :
			[
				{ type: 'goog-remb', parameter: '' },
				{ type: 'transport-cc', parameter: '' },
				{ type: 'ccm', parameter: 'fir' },
				{ type: 'nack', parameter: '' },
				{ type: 'nack', parameter: 'pli' }
			],
			parameters :
			{
				baz : '1234abcd'
			}
		});

	expect(codecs[1]).toEqual(
		{
			mimeType     : 'video/rtx',
			payloadType  : 97,
			clockRate    : 90000,
			rtcpFeedback : [],
			parameters   :
			{
				apt : 96
			}
		});

	headerExtensions = videoProducer.rtpParameters.headerExtensions;

	expect(headerExtensions).toEqual(
		[
			{
				uri        : 'urn:ietf:params:rtp-hdrext:sdes:mid',
				id         : 1,
				encrypt    : false,
				parameters : {}
			},
			{
				uri        : 'http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time',
				id         : 3,
				encrypt    : false,
				parameters : {}
			},
			{
				uri        : 'http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01',
				id         : 5,
				encrypt    : false,
				parameters : {}
			},
			{
				uri        : 'urn:3gpp:video-orientation',
				id         : 4,
				encrypt    : false,
				parameters : {}
			},
			{
				uri        : 'urn:ietf:params:rtp-hdrext:toffset',
				id         : 2,
				encrypt    : false,
				parameters : {}
			}
		]);

	encodings = videoProducer.rtpParameters.encodings;

	expect(Array.isArray(encodings)).toBe(true);
	expect(encodings!.length).toBe(2);
	expect(typeof encodings?.[0]).toBe('object');
	expect(typeof encodings?.[0].ssrc).toBe('number');
	expect(typeof encodings?.[0].rtx).toBe('object');
	expect(Object.keys(encodings![0].rtx!)).toEqual([ 'ssrc' ]);
	expect(typeof encodings?.[0].rtx?.ssrc).toBe('number');
	expect(typeof encodings?.[1]).toBe('object');
	expect(typeof encodings?.[1].ssrc).toBe('number');
	expect(typeof encodings?.[1].rtx).toBe('object');
	expect(Object.keys(encodings![1].rtx!)).toEqual([ 'ssrc' ]);
	expect(typeof encodings?.[1].rtx?.ssrc).toBe('number');

	rtcp = videoProducer.rtpParameters.rtcp;

	expect(typeof rtcp).toBe('object');
	expect(typeof rtcp?.cname).toBe('string');

	expect(videoProducer.paused).toBe(false);
	expect(videoProducer.maxSpatialLayer).toBe(undefined);
	expect(videoProducer.appData).toEqual({});

	sendTransport.removeAllListeners('connect');
	sendTransport.removeAllListeners('produce');
}, 500);

test('transport.produce() without track rejects with TypeError', async () =>
{
	await expect(sendTransport.produce({}))
		.rejects
		.toThrow(TypeError);
}, 500);

test('transport.produce() in a receiving Transport rejects with UnsupportedError', async () =>
{
	const track = new FakeMediaStreamTrack({ kind: 'audio' });

	await expect(recvTransport.produce({ track }))
		.rejects
		.toThrow(UnsupportedError);
}, 500);

test('transport.produce() with an ended track rejects with InvalidStateError', async () =>
{
	const track = new FakeMediaStreamTrack({ kind: 'audio' });

	track.stop();

	await expect(sendTransport.produce({ track }))
		.rejects
		.toThrow(InvalidStateError);
}, 500);

test('transport.produce() with a non object appData rejects with TypeError', async () =>
{
	const track = new FakeMediaStreamTrack({ kind: 'audio' });

	// @ts-ignore
	await expect(sendTransport.produce({ track, appData: true }))
		.rejects
		.toThrow(TypeError);
}, 500);

test('transport.consume() succeeds', async () =>
{
	const audioConsumerRemoteParameters =
		fakeParameters.generateConsumerRemoteParameters({ codecMimeType: 'audio/opus' });
	const videoConsumerRemoteParameters =
		fakeParameters.generateConsumerRemoteParameters({ codecMimeType: 'video/VP8' });
	let connectEventNumTimesCalled = 0;

	// eslint-disable-next-line no-unused-vars
	recvTransport.on('connect', ({ dtlsParameters }, callback /* errback */) =>
	{
		connectEventNumTimesCalled++;

		expect(typeof dtlsParameters).toBe('object');

		// Emulate communication with the server and success response (no response
		// data needed).
		setTimeout(callback);
	});

	let codecs;
	let headerExtensions;
	let encodings;
	let rtcp;

	audioConsumer = await recvTransport.consume<{ bar: string }>(
		{
			id            : audioConsumerRemoteParameters.id,
			producerId    : audioConsumerRemoteParameters.producerId,
			kind          : audioConsumerRemoteParameters.kind,
			rtpParameters : audioConsumerRemoteParameters.rtpParameters,
			appData       : { bar: 'BAR' }
		});

	expect(connectEventNumTimesCalled).toBe(1);
	expect(typeof audioConsumer).toBe('object');
	expect(audioConsumer.id).toBe(audioConsumerRemoteParameters.id);
	expect(audioConsumer.producerId).toBe(audioConsumerRemoteParameters.producerId);
	expect(audioConsumer.closed).toBe(false);
	expect(audioConsumer.kind).toBe('audio');
	expect(typeof audioConsumer.track).toBe('object');
	expect(typeof audioConsumer.rtpParameters).toBe('object');
	expect(audioConsumer.rtpParameters.mid).toBe(undefined);
	expect(audioConsumer.rtpParameters.codecs.length).toBe(1);

	codecs = audioConsumer.rtpParameters.codecs;

	expect(codecs[0]).toEqual(
		{
			mimeType     : 'audio/opus',
			payloadType  : 100,
			clockRate    : 48000,
			channels     : 2,
			rtcpFeedback :
			[
				{ type: 'transport-cc', parameter: '' }
			],
			parameters :
			{
				useinbandfec : 1,
				foo          : 'bar'
			}
		});

	headerExtensions = audioConsumer.rtpParameters.headerExtensions;

	expect(headerExtensions).toEqual(
		[
			{
				uri        : 'urn:ietf:params:rtp-hdrext:sdes:mid',
				id         : 1,
				encrypt    : false,
				parameters : {}
			},
			{
				uri        : 'http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01',
				id         : 5,
				encrypt    : false,
				parameters : {}
			},
			{
				uri        : 'urn:ietf:params:rtp-hdrext:ssrc-audio-level',
				id         : 10,
				encrypt    : false,
				parameters : {}
			}
		]);

	encodings = audioConsumer.rtpParameters.encodings;

	expect(Array.isArray(encodings)).toBe(true);
	expect(encodings!.length).toBe(1);
	expect(typeof encodings?.[0]).toBe('object');
	expect(Object.keys(encodings![0])).toEqual([ 'ssrc', 'dtx' ]);
	expect(typeof encodings![0].ssrc).toBe('number');

	rtcp = audioProducer.rtpParameters.rtcp;

	expect(typeof rtcp).toBe('object');
	expect(typeof rtcp?.cname).toBe('string');

	expect(audioConsumer.paused).toBe(false);
	expect(audioConsumer.appData).toEqual({ bar: 'BAR' });

	videoConsumer = await recvTransport.consume(
		{
			id            : videoConsumerRemoteParameters.id,
			producerId    : videoConsumerRemoteParameters.producerId,
			kind          : videoConsumerRemoteParameters.kind,
			rtpParameters : videoConsumerRemoteParameters.rtpParameters
		});

	expect(connectEventNumTimesCalled).toBe(1);
	expect(typeof videoConsumer).toBe('object');
	expect(videoConsumer.id).toBe(videoConsumerRemoteParameters.id);
	expect(videoConsumer.producerId).toBe(videoConsumerRemoteParameters.producerId);
	expect(videoConsumer.closed).toBe(false);
	expect(videoConsumer.kind).toBe('video');
	expect(typeof videoConsumer.track).toBe('object');
	expect(typeof videoConsumer.rtpParameters).toBe('object');
	expect(videoConsumer.rtpParameters.mid).toBe(undefined);
	expect(videoConsumer.rtpParameters.codecs.length).toBe(2);

	codecs = videoConsumer.rtpParameters.codecs;

	expect(codecs[0]).toEqual(
		{
			mimeType     : 'video/VP8',
			payloadType  : 101,
			clockRate    : 90000,
			rtcpFeedback :
			[
				{ type: 'nack', parameter: '' },
				{ type: 'nack', parameter: 'pli' },
				{ type: 'ccm', parameter: 'fir' },
				{ type: 'goog-remb', parameter: '' },
				{ type: 'transport-cc', parameter: '' }
			],
			parameters :
			{
				'x-google-start-bitrate' : 1500
			}
		});

	expect(codecs[1]).toEqual(
		{
			mimeType     : 'video/rtx',
			payloadType  : 102,
			clockRate    : 90000,
			rtcpFeedback : [],
			parameters   :
			{
				apt : 101
			}
		});

	headerExtensions = videoConsumer.rtpParameters.headerExtensions;

	expect(headerExtensions).toEqual(
		[
			{
				uri        : 'urn:ietf:params:rtp-hdrext:sdes:mid',
				id         : 1,
				encrypt    : false,
				parameters : {}
			},
			{
				uri        : 'http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time',
				id         : 4,
				encrypt    : false,
				parameters : {}
			},
			{
				uri        : 'http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01',
				id         : 5,
				encrypt    : false,
				parameters : {}
			},
			{
				uri        : 'urn:3gpp:video-orientation',
				id         : 11,
				encrypt    : false,
				parameters : {}
			},
			{
				uri        : 'urn:ietf:params:rtp-hdrext:toffset',
				id         : 12,
				encrypt    : false,
				parameters : {}
			}
		]);

	encodings = videoConsumer.rtpParameters.encodings;

	expect(Array.isArray(encodings)).toBe(true);
	expect(encodings!.length).toBe(1);
	expect(typeof encodings?.[0]).toBe('object');
	expect(Object.keys(encodings![0])).toEqual([ 'ssrc', 'rtx', 'dtx' ]);
	expect(typeof encodings?.[0].ssrc).toBe('number');
	expect(typeof encodings?.[0].rtx).toBe('object');
	expect(Object.keys(encodings![0].rtx!)).toEqual([ 'ssrc' ]);
	expect(typeof encodings?.[0].rtx?.ssrc).toBe('number');

	rtcp = videoConsumer.rtpParameters.rtcp;

	expect(typeof rtcp).toBe('object');
	expect(typeof rtcp?.cname).toBe('string');

	expect(videoConsumer.paused).toBe(false);
	expect(videoConsumer.appData).toEqual({});

	recvTransport.removeAllListeners('connect');
}, 500);

test('transport.consume() batches consumers created in same macrotask into the same task', async () =>
{
	const videoConsumerRemoteParameters1 =
		fakeParameters.generateConsumerRemoteParameters({ codecMimeType: 'video/VP8' });
	const videoConsumerRemoteParameters2 =
		fakeParameters.generateConsumerRemoteParameters({ codecMimeType: 'video/VP8' });

	const pushSpy = jest.spyOn((recvTransport as unknown as { _awaitQueue: AwaitQueue })._awaitQueue, 'push');

	const waitForConsumer = (id: string | undefined): Promise<void> =>
	{
		return new Promise<void>((resolve) =>
		{
			recvTransport.observer.on('newconsumer', (consumer) =>
			{
				if (consumer.id === id)
				{
					resolve();
				}
			});
		});
	};

	const allConsumersCreated = Promise.all(
		[
			waitForConsumer(videoConsumerRemoteParameters1.id),
			waitForConsumer(videoConsumerRemoteParameters2.id)
		]);

	await Promise.all([
		recvTransport.consume(
			{
				id            : videoConsumerRemoteParameters1.id,
				producerId    : videoConsumerRemoteParameters1.producerId,
				kind          : videoConsumerRemoteParameters1.kind,
				rtpParameters : videoConsumerRemoteParameters1.rtpParameters
			}),
		recvTransport.consume(
			{
				id            : videoConsumerRemoteParameters2.id,
				producerId    : videoConsumerRemoteParameters2.producerId,
				kind          : videoConsumerRemoteParameters2.kind,
				rtpParameters : videoConsumerRemoteParameters2.rtpParameters
			})
	]);

	await allConsumersCreated;

	expect(pushSpy).toBeCalledTimes(1);
}, 500);

test('transport.consume() without remote Consumer parameters rejects with TypeError', async () =>
{
	// @ts-ignore
	await expect(recvTransport.consume({}))
		.rejects
		.toThrow(TypeError);
}, 500);

test('transport.consume() with missing remote Consumer parameters rejects with TypeError', async () =>
{
	// @ts-ignore
	await expect(recvTransport.consume({ id: '1234' }))
		.rejects
		.toThrow(TypeError);

	// @ts-ignore
	await expect(recvTransport.consume({ id: '1234', producerId: '4444' }))
		.rejects
		.toThrow(TypeError);

	await expect(recvTransport.consume(
		// @ts-ignore
		{
			id         : '1234',
			producerId : '4444',
			kind       : 'audio'
		}))
		.rejects
		.toThrow(TypeError);

	await expect(recvTransport.consume(
		// @ts-ignore
		{
			id         : '1234',
			producerId : '4444',
			kind       : 'audio'
		}))
		.rejects
		.toThrow(TypeError);
}, 500);

test('transport.consume() in a sending Transport rejects with UnsupportedError', async () =>
{
	const {
		id,
		producerId,
		kind,
		rtpParameters
	} = fakeParameters.generateConsumerRemoteParameters({ codecMimeType: 'audio/opus' });

	await expect(sendTransport.consume(
		{
			id,
			producerId,
			kind,
			rtpParameters
		}))
		.rejects
		.toThrow(UnsupportedError);
}, 500);

test('transport.consume() with unsupported rtpParameters rejects with UnsupportedError', async () =>
{
	const {
		id,
		producerId,
		kind,
		rtpParameters
	} = fakeParameters.generateConsumerRemoteParameters({ codecMimeType: 'audio/ISAC' });

	await expect(sendTransport.consume(
		{
			id,
			producerId,
			kind,
			rtpParameters
		}))
		.rejects
		.toThrow(UnsupportedError);
}, 500);

test('transport.consume() with a non object appData rejects with TypeError', async () =>
{
	const consumerRemoteParameters =
		fakeParameters.generateConsumerRemoteParameters({ codecMimeType: 'audio/opus' });

	// @ts-ignore
	await expect(recvTransport.consume({ consumerRemoteParameters, appData: true }))
		.rejects
		.toThrow(TypeError);
}, 500);

test('transport.produceData() succeeds', async () =>
{
	let dataProducerId;
	let produceDataEventNumTimesCalled = 0;

	// eslint-disable-next-line no-unused-vars
	sendTransport.on('producedata', ({ sctpStreamParameters, label, protocol, appData }, callback /* errback */) =>
	{
		produceDataEventNumTimesCalled++;

		expect(typeof sctpStreamParameters).toBe('object');
		expect(label).toBe('FOO');
		expect(protocol).toBe('BAR');
		expect(appData).toEqual({ foo: 'FOO' });

		const id = fakeParameters.generateDataProducerRemoteParameters().id;

		dataProducerId = id;

		// Emulate communication with the server and success response with Producer
		// remote parameters.
		setTimeout(() => callback({ id }));
	});

	dataProducer = await sendTransport.produceData<{ foo: string }>(
		{
			ordered           : false,
			maxPacketLifeTime : 5555,
			label             : 'FOO',
			protocol          : 'BAR',
			appData           : { foo: 'FOO' }
		});

	expect(produceDataEventNumTimesCalled).toBe(1);
	expect(typeof dataProducer).toBe('object');
	expect(dataProducer.id).toBe(dataProducerId);
	expect(dataProducer.closed).toBe(false);
	expect(typeof dataProducer.sctpStreamParameters).toBe('object');
	expect(typeof dataProducer.sctpStreamParameters.streamId).toBe('number');
	expect(dataProducer.sctpStreamParameters.ordered).toBe(false);
	expect(dataProducer.sctpStreamParameters.maxPacketLifeTime).toBe(5555);
	expect(dataProducer.sctpStreamParameters.maxRetransmits).toBe(undefined);
	expect(dataProducer.label).toBe('FOO');
	expect(dataProducer.protocol).toBe('BAR');

	sendTransport.removeAllListeners('producedata');
}, 500);

test('transport.produceData() in a receiving Transport rejects with UnsupportedError', async () =>
{
	await expect(recvTransport.produceData({}))
		.rejects
		.toThrow(UnsupportedError);
}, 500);

test('transport.produceData() with a non object appData rejects with TypeError', async () =>
{
	// @ts-ignore
	await expect(sendTransport.produceData({ appData: true }))
		.rejects
		.toThrow(TypeError);
}, 500);

test('transport.consumeData() succeeds', async () =>
{
	const dataConsumerRemoteParameters =
		fakeParameters.generateDataConsumerRemoteParameters();

	dataConsumer = await recvTransport.consumeData<{ bar: string }>(
		{
			id                   : dataConsumerRemoteParameters.id,
			dataProducerId       : dataConsumerRemoteParameters.dataProducerId,
			sctpStreamParameters : dataConsumerRemoteParameters.sctpStreamParameters,
			label                : 'FOO',
			protocol             : 'BAR',
			appData              : { bar: 'BAR' }
		});

	expect(typeof dataConsumer).toBe('object');
	expect(dataConsumer.id).toBe(dataConsumerRemoteParameters.id);
	expect(dataConsumer.dataProducerId).toBe(dataConsumerRemoteParameters.dataProducerId);
	expect(dataConsumer.closed).toBe(false);
	expect(typeof dataConsumer.sctpStreamParameters).toBe('object');
	expect(typeof dataConsumer.sctpStreamParameters.streamId).toBe('number');
	expect(dataConsumer.label).toBe('FOO');
	expect(dataConsumer.protocol).toBe('BAR');
}, 500);

test('transport.consumeData() without remote DataConsumer parameters rejects with TypeError', async () =>
{
	// @ts-ignore
	await expect(recvTransport.consumeData({}))
		.rejects
		.toThrow(TypeError);
}, 500);

test('transport.consumeData() with missing remote DataConsumer parameters rejects with TypeError', async () =>
{
	// @ts-ignore
	await expect(recvTransport.consumeData({ id: '1234' }))
		.rejects
		.toThrow(TypeError);

	// @ts-ignore
	await expect(recvTransport.consumeData({ id: '1234', dataProducerId: '4444' }))
		.rejects
		.toThrow(TypeError);
}, 500);

test('transport.consumeData() in a sending Transport rejects with UnsupportedError', async () =>
{
	const {
		id,
		dataProducerId,
		sctpStreamParameters
	} = fakeParameters.generateDataConsumerRemoteParameters();

	await expect(sendTransport.consumeData(
		{
			id,
			dataProducerId,
			sctpStreamParameters
		}))
		.rejects
		.toThrow(UnsupportedError);
}, 500);

test('transport.consumeData() with a non object appData rejects with TypeError', async () =>
{
	const dataConsumerRemoteParameters =
		fakeParameters.generateDataConsumerRemoteParameters();

	// @ts-ignore
	await expect(recvTransport.consumeData({ dataConsumerRemoteParameters, appData: true }))
		.rejects
		.toThrow(TypeError);
}, 500);

test('transport.getStats() succeeds', async () =>
{
	const stats = await sendTransport.getStats();

	expect(typeof stats).toBe('object');
}, 500);

test('transport.restartIce() succeeds', async () =>
{
	await expect(sendTransport.restartIce(
		{
			iceParameters :
			{
				usernameFragment : 'foo',
				password         : 'xxx'
			}
		}))
		.resolves
		.toBe(undefined);
}, 500);

test('transport.restartIce() without remote iceParameters rejects with TypeError', async () =>
{
	// @ts-ignore
	await expect(sendTransport.restartIce({}))
		.rejects
		.toThrow(TypeError);
}, 500);

test('transport.updateIceServers() succeeds', async () =>
{
	await expect(sendTransport.updateIceServers({ iceServers: [] }))
		.resolves
		.toBe(undefined);
}, 500);

test('transport.updateIceServers() without iceServers rejects with TypeError', async () =>
{
	await expect(sendTransport.updateIceServers({}))
		.rejects
		.toThrow(TypeError);
}, 500);

test('ICE gathering state change fires "icegatheringstatechange" in live Transport', () =>
{
	// NOTE: These tests are a bit flaky and we should isolate them. FakeHandler
	// emits '@connectionstatechange' with value 'connecting' as soon as its
	// private setupTransport() method is called (which has happens many times in
	// tests above already). So here we have to reset it manually to test things.

	// @ts-ignore
	sendTransport.handler.setIceGatheringState('new');
	// @ts-ignore
	sendTransport.handler.setConnectionState('new');

	let iceGatheringStateChangeEventNumTimesCalled = 0;
	let connectionStateChangeEventNumTimesCalled = 0;

	sendTransport.on('icegatheringstatechange', (iceGatheringState) =>
	{
		iceGatheringStateChangeEventNumTimesCalled++;

		expect(iceGatheringState).toBe('complete');
		expect(sendTransport.iceGatheringState).toBe('complete');
		expect(sendTransport.connectionState).toBe('new');
	});

	// eslint-disable-next-line @typescript-eslint/no-unused-vars
	sendTransport.on('connectionstatechange', (connectionState) =>
	{
		connectionStateChangeEventNumTimesCalled++;
	});

	// @ts-ignore
	sendTransport.handler.setIceGatheringState('complete');

	expect(iceGatheringStateChangeEventNumTimesCalled).toBe(1);
	expect(connectionStateChangeEventNumTimesCalled).toBe(0);
	expect(sendTransport.iceGatheringState).toBe('complete');
	expect(sendTransport.connectionState).toBe('new');

	sendTransport.removeAllListeners('icegatheringstatechange');
	sendTransport.removeAllListeners('connectionstatechange');
});

test('connection state change fires "connectionstatechange" in live Transport', () =>
{
	let connectionStateChangeEventNumTimesCalled = 0;

	sendTransport.on('connectionstatechange', (connectionState) =>
	{
		connectionStateChangeEventNumTimesCalled++;

		expect(connectionState).toBe('completed');
	});

	// @ts-ignore
	sendTransport.handler.setConnectionState('completed');

	expect(connectionStateChangeEventNumTimesCalled).toBe(1);
	expect(sendTransport.connectionState).toBe('completed');

	sendTransport.removeAllListeners('connectionstatechange');
});

test('producer.pause() succeeds', () =>
{
	videoProducer.pause();

	expect(videoProducer.paused).toBe(true);
	// Track will be still enabled due to disableTrackOnPause: false.
	expect(videoProducer.track?.enabled).toBe(true);
});

test('producer.resume() succeeds', () =>
{
	videoProducer.resume();

	expect(videoProducer.paused).toBe(false);
	expect(videoProducer.track?.enabled).toBe(true);
});

test('producer.replaceTrack() with a new track succeeds', async () =>
{
	// Have the audio Producer paused.
	audioProducer.pause();

	const audioProducerPreviousTrack = audioProducer.track;
	const newAudioTrack = new FakeMediaStreamTrack({ kind: 'audio' });

	await expect(audioProducer.replaceTrack({ track: newAudioTrack }))
		.resolves
		.toBe(undefined);

	// Previous track must be 'live' due to stopTracks: false.
	expect(audioProducerPreviousTrack?.readyState).toBe('live');
	expect(audioProducer.track?.readyState).toBe('live');
	expect(audioProducer.track).not.toBe(audioProducerPreviousTrack);
	expect(audioProducer.track).toBe(newAudioTrack);
	// Producer was already paused.
	expect(audioProducer.paused).toBe(true);

	// Reset the audio paused state.
	audioProducer.resume();

	const videoProducerPreviousTrack = videoProducer.track;
	const newVideoTrack = new FakeMediaStreamTrack({ kind: 'video' });

	await expect(videoProducer.replaceTrack({ track: newVideoTrack }))
		.resolves
		.toBe(undefined);

	// Previous track must be 'ended' due to stopTracks: true.
	expect(videoProducerPreviousTrack?.readyState).toBe('ended');
	expect(videoProducer.track).not.toBe(videoProducerPreviousTrack);
	expect(videoProducer.track).toBe(newVideoTrack);
	expect(videoProducer.paused).toBe(false);
}, 500);

test('producer.replaceTrack() with null succeeds', async () =>
{
	// Have the audio Producer paused.
	audioProducer.pause();

	const audioProducerPreviousTrack = audioProducer.track;

	await expect(audioProducer.replaceTrack({ track: null }))
		.resolves
		.toBe(undefined);

	// Previous track must be 'live' due to stopTracks: false.
	expect(audioProducerPreviousTrack?.readyState).toBe('live');
	expect(audioProducer.track).toBeNull();
	// Producer was already paused.
	expect(audioProducer.paused).toBe(true);

	// Reset the audio paused state.
	audioProducer.resume();

	expect(audioProducer.paused).toBe(false);

	// Manually "mute" the original audio track.
	audioProducerPreviousTrack!.enabled = false;

	// Set the original audio track back.
	await expect(audioProducer.replaceTrack({ track: audioProducerPreviousTrack }))
		.resolves
		.toBe(undefined);

	// The given audio track was muted but the Producer was not, so the track
	// must not be muted now.
	expect(audioProducer.paused).toBe(false);
	expect(audioProducerPreviousTrack?.enabled).toBe(true);

	// Reset the audio paused state.
	audioProducer.resume();
}, 500);

test('producer.replaceTrack() with an ended track rejects with InvalidStateError', async () =>
{
	const track = new FakeMediaStreamTrack({ kind: 'audio' });

	track.stop();

	await expect(videoProducer.replaceTrack({ track }))
		.rejects
		.toThrow(InvalidStateError);

	expect(track.readyState).toBe('ended');
	expect(videoProducer.track?.readyState).toBe('live');
}, 500);

test('producer.replaceTrack() with the same track succeeds', async () =>
{
	await expect(audioProducer.replaceTrack({ track: audioProducer.track }))
		.resolves
		.toBe(undefined);

	expect(audioProducer.track?.readyState).toBe('live');
}, 500);

test('producer.setMaxSpatialLayer() succeeds', async () =>
{
	await expect(videoProducer.setMaxSpatialLayer(0))
		.resolves
		.toBe(undefined);

	expect(videoProducer.maxSpatialLayer).toBe(0);
}, 500);

test('producer.setMaxSpatialLayer() in an audio Producer rejects with UnsupportedError', async () =>
{
	await expect(audioProducer.setMaxSpatialLayer(1))
		.rejects
		.toThrow(UnsupportedError);

	expect(audioProducer.maxSpatialLayer).toBe(undefined);
}, 500);

test('producer.setMaxSpatialLayer() with invalid spatialLayer rejects with TypeError', async () =>
{
	// @ts-ignore
	await expect(videoProducer.setMaxSpatialLayer('chicken'))
		.rejects
		.toThrow(TypeError);
}, 500);

test('producer.setMaxSpatialLayer() without spatialLayer rejects with TypeError', async () =>
{
	// @ts-ignore
	await expect(videoProducer.setMaxSpatialLayer())
		.rejects
		.toThrow(TypeError);
}, 500);

test('producer.setRtpEncodingParameters() succeeds', async () =>
{
	await expect(videoProducer.setRtpEncodingParameters({ scaleResolutionDownBy: 2 }))
		.resolves
		.toBe(undefined);

	expect(videoProducer.maxSpatialLayer).toBe(0);
}, 500);

test('producer.getStats() succeeds', async () =>
{
	const stats = await videoProducer.getStats();

	expect(typeof stats).toBe('object');
}, 500);

test('consumer.resume() succeeds', () =>
{
	videoConsumer.resume();

	expect(videoConsumer.paused).toBe(false);
});

test('consumer.pause() succeeds', () =>
{
	videoConsumer.pause();

	expect(videoConsumer.paused).toBe(true);
});

test('consumer.getStats() succeeds', async () =>
{
	const stats = await videoConsumer.getStats();

	expect(typeof stats).toBe('object');
}, 500);

test('producer.close() succeed', () =>
{
	audioProducer.close();

	expect(audioProducer.closed).toBe(true);
	// Track will be still 'live' due to stopTracks: false.
	expect(audioProducer.track?.readyState).toBe('live');
});

test('producer.replaceTrack() rejects with InvalidStateError if closed', async () =>
{
	const audioTrack = new FakeMediaStreamTrack({ kind: 'audio' });

	await expect(audioProducer.replaceTrack({ track: audioTrack }))
		.rejects
		.toThrow(InvalidStateError);

	expect(audioTrack.readyState).toBe('live');
}, 500);

test('producer.getStats() rejects with InvalidStateError if closed', async () =>
{
	await expect(audioProducer.getStats())
		.rejects
		.toThrow(InvalidStateError);
}, 500);

test('consumer.close() succeed', () =>
{
	audioConsumer.close();

	expect(audioConsumer.closed).toBe(true);
	expect(audioConsumer.track.readyState).toBe('ended');
});

test('consumer.getStats() rejects with InvalidStateError if closed', async () =>
{
	await expect(audioConsumer.getStats())
		.rejects
		.toThrow(InvalidStateError);
}, 500);

test('dataProducer.close() succeed', () =>
{
	dataProducer.close();

	expect(dataProducer.closed).toBe(true);
});

test('dataConsumer.close() succeed', () =>
{
	dataConsumer.close();

	expect(dataConsumer.closed).toBe(true);
});

test('remotetely stopped track fires "trackended" in live Producers/Consumers', () =>
{
	let audioProducerTrackendedEventCalled = false;
	let videoProducerTrackendedEventCalled = false;
	let audiosConsumerTrackendedEventCalled = false;
	let videoConsumerTrackendedEventCalled = false;

	audioProducer.on('trackended', () =>
	{
		audioProducerTrackendedEventCalled = true;
	});

	videoProducer.on('trackended', () =>
	{
		videoProducerTrackendedEventCalled = true;
	});

	audioConsumer.on('trackended', () =>
	{
		audiosConsumerTrackendedEventCalled = true;
	});

	videoConsumer.on('trackended', () =>
	{
		videoConsumerTrackendedEventCalled = true;
	});

	// @ts-ignore
	audioProducer.track.remoteStop();

	// Audio Producer was already closed.
	expect(audioProducerTrackendedEventCalled).toBe(false);

	// @ts-ignore
	videoProducer.track.remoteStop();

	expect(videoProducerTrackendedEventCalled).toBe(true);

	// @ts-ignore
	audioConsumer.track.remoteStop();

	// Audio Consumer was already closed.
	expect(audiosConsumerTrackendedEventCalled).toBe(false);

	// @ts-ignore
	videoConsumer.track.remoteStop();

	expect(videoConsumerTrackendedEventCalled).toBe(true);

	audioProducer.removeAllListeners();
	videoProducer.removeAllListeners();
	audioConsumer.removeAllListeners();
	videoConsumer.removeAllListeners();
});

test('transport.close() fires "transportclose" in live Producers/Consumers', () =>
{
	let audioProducerTransportcloseEventCalled = false;
	let videoProducerTransportcloseEventCalled = false;
	let audioConsumerTransportcloseEventCalled = false;
	let videoConsumerTransportcloseEventCalled = false;

	audioProducer.on('transportclose', () =>
	{
		audioProducerTransportcloseEventCalled = true;
	});

	videoProducer.on('transportclose', () =>
	{
		videoProducerTransportcloseEventCalled = true;
	});

	audioConsumer.on('transportclose', () =>
	{
		audioConsumerTransportcloseEventCalled = true;
	});

	videoConsumer.on('transportclose', () =>
	{
		videoConsumerTransportcloseEventCalled = true;
	});

	// Audio Producer was already closed.
	expect(audioProducer.closed).toBe(true);
	expect(videoProducer.closed).toBe(false);

	sendTransport.close();

	expect(sendTransport.closed).toBe(true);
	expect(videoProducer.closed).toBe(true);
	// Audio Producer was already closed.
	expect(audioProducerTransportcloseEventCalled).toBe(false);
	expect(videoProducerTransportcloseEventCalled).toBe(true);

	// Audio Consumer was already closed.
	expect(audioConsumer.closed).toBe(true);
	expect(videoConsumer.closed).toBe(false);

	recvTransport.close();

	expect(recvTransport.closed).toBe(true);
	expect(videoConsumer.closed).toBe(true);
	// Audio Consumer was already closed.
	expect(audioConsumerTransportcloseEventCalled).toBe(false);
	expect(videoConsumerTransportcloseEventCalled).toBe(true);

	audioProducer.removeAllListeners();
	videoProducer.removeAllListeners();
	audioConsumer.removeAllListeners();
	videoConsumer.removeAllListeners();
});

test('transport.produce() rejects with InvalidStateError if closed', async () =>
{
	const track = new FakeMediaStreamTrack({ kind: 'audio' });

	// Add noop listener to avoid the method fail.
	sendTransport.on('produce', () => {});

	await expect(sendTransport.produce({ track, stopTracks: false }))
		.rejects
		.toThrow(InvalidStateError);

	// The track must be 'live' due to stopTracks: false.
	expect(track.readyState).toBe('live');

	sendTransport.removeAllListeners('produce');
}, 500);

test('transport.consume() rejects with InvalidStateError if closed', async () =>
{
	// @ts-ignore
	await expect(recvTransport.consume({}))
		.rejects
		.toThrow(InvalidStateError);

	recvTransport.removeAllListeners();
}, 500);

test('transport.produceData() rejects with InvalidStateError if closed', async () =>
{
	// Add noop listener to avoid the method fail.
	sendTransport.on('producedata', () => {});

	await expect(sendTransport.produceData({}))
		.rejects
		.toThrow(InvalidStateError);

	sendTransport.removeAllListeners('producedata');
}, 500);

test('transport.consumeData() rejects with InvalidStateError if closed', async () =>
{
	// @ts-ignore
	await expect(recvTransport.consumeData({}))
		.rejects
		.toThrow(InvalidStateError);
}, 500);

test('transport.getStats() rejects with InvalidStateError if closed', async () =>
{
	await expect(sendTransport.getStats())
		.rejects
		.toThrow(InvalidStateError);
}, 500);

test('transport.restartIce() rejects with InvalidStateError if closed', async () =>
{
	// @ts-ignore
	await expect(sendTransport.restartIce({ ieParameters: {} }))
		.rejects
		.toThrow(InvalidStateError);
}, 500);

test('transport.updateIceServers() rejects with InvalidStateError if closed', async () =>
{
	await expect(sendTransport.updateIceServers({ iceServers: [] }))
		.rejects
		.toThrow(InvalidStateError);
}, 500);

test('connection state change does not fire "connectionstatechange" in closed Transport', () =>
{
	let connectionStateChangeEventNumTimesCalled = 0;

	// eslint-disable-next-line no-unused-vars
	sendTransport.on('connectionstatechange', (/* connectionState */) =>
	{
		connectionStateChangeEventNumTimesCalled++;
	});

	// @ts-ignore
	sendTransport.handler.setConnectionState('disconnected');

	expect(connectionStateChangeEventNumTimesCalled).toBe(0);
	expect(sendTransport.connectionState).toBe('disconnected');

	sendTransport.removeAllListeners('connectionstatechange');
});

test('RemoteSdp properly handles multiple streams of the same type in planB', async () =>
{
	let sdp = undefined;
	let sdpObject = undefined;

	const remoteSdp = new RemoteSdp({ planB: true });

	await remoteSdp.receive(
		{
			mid                : 'video',
			kind               : 'video',
			offerRtpParameters : fakeParameters.generateConsumerRemoteParameters({ codecMimeType: 'video/VP8' }).rtpParameters,
			streamId           : 'streamId-1',
			trackId            : 'trackId-1'
		});

	sdp = remoteSdp.getSdp();
	sdpObject = sdpTransform.parse(sdp);

	expect(sdpObject.media.length).toBe(1);
	expect(sdpObject.media[0].payloads).toBe('101 102');
	expect(sdpObject.media[0].rtp.length).toBe(2);
	expect(sdpObject.media[0].rtp[0].payload).toBe(101);
	expect(sdpObject.media[0].rtp[0].codec).toBe('VP8');
	expect(sdpObject.media[0].rtp[1].payload).toBe(102);
	expect(sdpObject.media[0].rtp[1].codec).toBe('rtx');
	expect(sdpObject.media[0].ssrcs?.length).toBe(4);

	await remoteSdp.receive(
		{
			mid                : 'video',
			kind               : 'video',
			offerRtpParameters : fakeParameters.generateConsumerRemoteParameters({ codecMimeType: 'video/H264' }).rtpParameters,
			streamId           : 'streamId-2',
			trackId            : 'trackId-2'
		});

	sdp = remoteSdp.getSdp();
	sdpObject = sdpTransform.parse(sdp);

	expect(sdpObject.media.length).toBe(1);
	expect(sdpObject.media[0].payloads).toBe('101 102 103 104');
	expect(sdpObject.media[0].rtp.length).toBe(4);
	expect(sdpObject.media[0].rtp[0].payload).toBe(101);
	expect(sdpObject.media[0].rtp[0].codec).toBe('VP8');
	expect(sdpObject.media[0].rtp[1].payload).toBe(102);
	expect(sdpObject.media[0].rtp[1].codec).toBe('rtx');
	expect(sdpObject.media[0].rtp[2].payload).toBe(103);
	expect(sdpObject.media[0].rtp[2].codec).toBe('H264');
	expect(sdpObject.media[0].rtp[3].payload).toBe(104);
	expect(sdpObject.media[0].rtp[3].codec).toBe('rtx');
	expect(sdpObject.media[0].ssrcs?.length).toBe(8);

	await remoteSdp.planBStopReceiving(
		{
			mid                : 'video',
			offerRtpParameters : fakeParameters.generateConsumerRemoteParameters({ codecMimeType: 'video/H264' }).rtpParameters
		});

	sdp = remoteSdp.getSdp();
	sdpObject = sdpTransform.parse(sdp);

	expect(sdpObject.media.length).toBe(1);
	expect(sdpObject.media[0].payloads).toBe('101 102 103 104');
	expect(sdpObject.media[0].rtp.length).toBe(4);
	expect(sdpObject.media[0].rtp[0].payload).toBe(101);
	expect(sdpObject.media[0].rtp[0].codec).toBe('VP8');
	expect(sdpObject.media[0].rtp[1].payload).toBe(102);
	expect(sdpObject.media[0].rtp[1].codec).toBe('rtx');
	expect(sdpObject.media[0].rtp[2].payload).toBe(103);
	expect(sdpObject.media[0].rtp[2].codec).toBe('H264');
	expect(sdpObject.media[0].rtp[3].payload).toBe(104);
	expect(sdpObject.media[0].rtp[3].codec).toBe('rtx');
	expect(sdpObject.media[0].ssrcs?.length).toBe(4);
}, 500);

test('RemoteSdp does not duplicate codec descriptions', async () =>
{
	let sdp = undefined;
	let sdpObject = undefined;

	const remoteSdp = new RemoteSdp({ planB: true });

	await remoteSdp.receive(
		{
			mid                : 'video',
			kind               : 'video',
			offerRtpParameters : fakeParameters.generateConsumerRemoteParameters({ codecMimeType: 'video/VP8' }).rtpParameters,
			streamId           : 'streamId-1',
			trackId            : 'trackId-1'
		});

	sdp = remoteSdp.getSdp();
	sdpObject = sdpTransform.parse(sdp);

	expect(sdpObject.media.length).toBe(1);
	expect(sdpObject.media[0].payloads).toBe('101 102');
	expect(sdpObject.media[0].rtp.length).toBe(2);
	expect(sdpObject.media[0].rtp[0].payload).toBe(101);
	expect(sdpObject.media[0].rtp[0].codec).toBe('VP8');
	expect(sdpObject.media[0].rtp[1].payload).toBe(102);
	expect(sdpObject.media[0].rtp[1].codec).toBe('rtx');
	expect(sdpObject.media[0].ssrcs?.length).toBe(4);

	await remoteSdp.receive(
		{
			mid                : 'video',
			kind               : 'video',
			offerRtpParameters : fakeParameters.generateConsumerRemoteParameters({ codecMimeType: 'video/VP8' }).rtpParameters,
			streamId           : 'streamId-1',
			trackId            : 'trackId-1'
		});

	sdp = remoteSdp.getSdp();
	sdpObject = sdpTransform.parse(sdp);

	expect(sdpObject.media.length).toBe(1);
	expect(sdpObject.media[0].payloads).toBe('101 102');
	expect(sdpObject.media[0].rtp.length).toBe(2);
	expect(sdpObject.media[0].rtp[0].payload).toBe(101);
	expect(sdpObject.media[0].rtp[0].codec).toBe('VP8');
	expect(sdpObject.media[0].rtp[1].payload).toBe(102);
	expect(sdpObject.media[0].rtp[1].codec).toBe('rtx');
	expect(sdpObject.media[0].ssrcs?.length).toBe(8);
}, 500);

test('parseScalabilityMode() works', () =>
{
	expect(parseScalabilityMode('L1T3')).toEqual({ spatialLayers: 1, temporalLayers: 3 });
	expect(parseScalabilityMode('L3T2_KEY')).toEqual({ spatialLayers: 3, temporalLayers: 2 });
	expect(parseScalabilityMode('S2T3')).toEqual({ spatialLayers: 2, temporalLayers: 3 });
	expect(parseScalabilityMode('foo')).toEqual({ spatialLayers: 1, temporalLayers: 1 });
	expect(parseScalabilityMode()).toEqual({ spatialLayers: 1, temporalLayers: 1 });
	expect(parseScalabilityMode('S0T3')).toEqual({ spatialLayers: 1, temporalLayers: 1 });
	expect(parseScalabilityMode('S1T0')).toEqual({ spatialLayers: 1, temporalLayers: 1 });
	expect(parseScalabilityMode('L20T3')).toEqual({ spatialLayers: 20, temporalLayers: 3 });
	expect(parseScalabilityMode('S200T3')).toEqual({ spatialLayers: 1, temporalLayers: 1 });
});

describe('detectDevice() assigns proper handler based on UserAgent', () =>
{
	const originalNavigator = global.navigator;

	for (const uaTestCase of uaTestCases)
	{
		test(uaTestCase.desc, () =>
		{
			// @ts-ignore
			global.navigator =
			{
				userAgent : uaTestCase.ua
			};

			const originalRTCRtpTransceiver = global.RTCRtpTransceiver;

			if (uaTestCase.expect === 'Safari12') 
			{
				global.RTCRtpTransceiver = class Dummy 
				{
					currentDirection()
					{}
				} as any;
			}

			expect(detectDevice()).toBe(uaTestCase.expect);

			// Cleanup.
			global.RTCRtpTransceiver = originalRTCRtpTransceiver;
		}, 100);
	}

	// Cleanup.
	global.navigator = originalNavigator;
});
